Skip to content

feat(signing): ship signing/signingtest subpackage + ObserveOnly mode - #479

Closed
sujanchalla0510 wants to merge 2 commits into
adcontextprotocol:mainfrom
sujanchalla0510:feat/signing-signingtest-observeonly
Closed

feat(signing): ship signing/signingtest subpackage + ObserveOnly mode#479
sujanchalla0510 wants to merge 2 commits into
adcontextprotocol:mainfrom
sujanchalla0510:feat/signing-signingtest-observeonly

Conversation

@sujanchalla0510

Copy link
Copy Markdown
Collaborator

Closes #53.

Summary

  • adcp/v3/signing/signingtestNewTestAgent(t) builds a matched Ed25519 *signing.Signer + signing.MiddlewareOptions (a fresh StaticJWKSResolver seeded with the signer's public key, a fresh NewMemoryReplayStore(0), no revocation). SignAndSend(t, signer, handler, req) signs req (with CoverContentDigest: true) and delivers it to handler in-process via httptest.NewRecorder, returning the *http.Response. Together these collapse the ~30-line keypair/JWK/resolver/replay-store pattern middleware_test.go currently hand-rolls into two lines of setup + one line to send.
  • MiddlewareOptions.ObserveOnly bool — a real behavioral branch in Middleware(), not a passthrough field. When true, VerifyRequestSignature still runs; on failure the request is logged at slog.LevelInfo (vs the normal slog.LevelWarn) with an observe_only=true attribute and passed to next.ServeHTTP with no VerifiedSigner in its context — i.e., treated exactly as unsigned. On success, behavior is unchanged.

ObserveOnly ↔ spec mapping

This maps to the AdCP transport spec's warn_for rollout stop (supported_for → warn_for → required_for, see Transport capability advertisement). Per spec: "A missing signature or a well-formed signature that fails verification or body binding MUST NOT establish verified-signer identity ... A partial or malformed Signature / Signature-Input pair always hard-rejects."

ObserveOnly implements that split precisely, not just "log and let everything through":

  • A well-formed signature that fails verification (bad crypto, unknown/revoked key, expired window, replay, wrong content-digest, an unsigned request to a RequiredFor op, ...) is observed: INFO log, request passes through unauthenticated.
  • A partial or malformed Signature/Signature-Input header pair — one header present without the other, or either header present but unparseable (*Error{Code: CodeHeaderMalformed}) — still hard-rejects with 401 even under ObserveOnly, because the spec says it "cannot be safely interpreted as either signed or unsigned traffic."

adcp/v3/signing/MIGRATION.md's "Step B — warn_for" section previously described this exact mode as not yet landed, with an OnReject-based logs-and-passes-through shim as the interim workaround (and a "delete this before enabling RequiredFor" warning). This PR replaces that shim guidance with the real ObserveOnly API throughout the migration guide (bootstrap → step B → common pitfalls → pre-enforcement checklist).

Module note

This targets adcp/v3/signing, not adcp/signing. Per the repo's README.md "Modules & versioning" table and MIGRATING.md, adcp (the pre-v3 module) is frozen at v2.1.1 and receives security backports only — adcp/v3 is the actively developed module for AdCP 3.x. A new DX/feature package belongs there, so adcp/signing (the frozen copy) is untouched by this PR.

Testing

  • signingtest's own tests (adcp/v3/signing/signingtest/signingtest_test.go) prove NewTestAgent + SignAndSend produce a request a real signing.Middleware-wrapped handler accepts (VerifiedSignerFromContext populated, correct algorithm), that the wired verifier is real and not a stub (rejects unsigned traffic on a RequiredFor op, rejects a replayed signature), and that SignAndSend fails fast on a non-absolute request URL (verified via a re-exec'd subprocess, since a subtest's t.Fatalf can't be observed without failing the parent test/package).
  • middleware_test.go gets three new tests: ObserveOnly lets an unsigned request to a RequiredFor operation through (INFO log, no VerifiedSigner); ObserveOnly lets a well-formed-but-cryptographically-invalid signature through, with a matching ObserveOnly=false subtest proving the existing 401 behavior is unchanged; and ObserveOnly still hard-rejects a malformed Signature/Signature-Input pair (WARN log, 401, WWW-Authenticate: ... request_signature_header_malformed), proving the spec carve-out is real.
  • go build ./..., go vet ./..., gofmt -l, and golangci-lint run ./signing/... are clean for every file this PR touches. The three staticcheck findings golangci-lint reports in adcp/v3/signing/jwk.go (deprecated ecdsa.PublicKey.X/.Y/.PrivateKey.D field access, Go 1.26) are pre-existing on main and untouched by this PR.
  • go test ./... passes for both the adcp/v3 module (this change) and the root module (unaffected).

On item 5 of the issue ("refactor an existing boilerplate-heavy test as a demonstration")

I looked for one and came up empty, honestly reported: the request-signing profile's StaticJWKSResolver/NewMemoryReplayStore boilerplate pattern this issue targets appears nowhere else in the repo outside adcp/signing and adcp/v3/signing themselves. adcp/v3/webhook's test files (signing_test.go, publisher_test.go, e2e_test.go) use the webhook-signing profile via a different, already-existing dedicated helper (webhookKeypair) and a different verifier entry point (webhook.HTTPHandler, not signing.Middleware) — not a good refactor target for signingtest, which is scoped to the request-signing/signing.Middleware path the issue describes.

I also couldn't refactor adcp/v3/signing/middleware_test.go's own TestMiddlewareEndToEndSignAndVerify (the literal ~30-line pattern quoted in the issue) in place: that file is package signing (internal, white-box), and signingtest imports signing — importing signingtest from inside package signing would be a cyclic import. Instead, signingtest's own TestNewTestAgentSignAndSendRoundTrip reproduces the same scenario (sign, verify via middleware, assert on VerifiedSignerFromContext) using the new two-line + one-line helpers, as the concrete demonstration of the line-count reduction, and the package's README.md/doc.go cross-reference it for discoverability.

Test plan

  • cd adcp/v3 && go build ./... && go vet ./... && go test ./...
  • cd adcp/v3 && golangci-lint run ./signing/...
  • go build ./... && go vet ./... && go test ./... at repo root (unaffected — confirms no accidental frozen-module edits)
  • git status clean in the working tree aside from the files this PR touches

Every consumer writing a handler test that expects signed requests had to
reverse-engineer the ~30-line keypair/JWK/StaticJWKSResolver/replay-store
pattern in middleware_test.go. signingtest.NewTestAgent + SignAndSend
collapse that into a two-line setup and a one-line send.

Separately, MiddlewareOptions.ObserveOnly implements the spec's warn_for
shadow-mode rollout stop (between supported_for and required_for):
verification still runs, but a failing request reaches next.ServeHTTP
with no VerifiedSigner in its context instead of getting a 401, and the
failure is logged at INFO. A partial or malformed Signature/Signature-Input
pair still hard-rejects even under ObserveOnly, per the spec's explicit
carve-out that such a pair can't be safely read as signed or unsigned
traffic. MIGRATION.md's step-B guidance, which previously described this
as a not-yet-landed OnReject shim, is updated to use the real API.

Built against adcp/v3/signing — the active module per README's
"Modules & versioning" table; adcp/signing (v2) is frozen for security
backports only, so this feature does not touch it.

Closes adcontextprotocol#53
@garvitkaushik-123

Copy link
Copy Markdown
Collaborator

Could you take a look at what happens to the request body when ObserveOnly is enabled and the body exceeds MaxBodyBytes?

I reproduced this locally with the body 0123456789abcdef and a limit of 8 bytes. The downstream handler received only 9abcdef. The body reader consumes the first 9 bytes and closes the original body before reporting the limit error. ObserveOnly then forwards that same request, without restoring the consumed bytes.

Could we make sure a body-size or read failure cannot reach the handler with an incomplete body? Rejecting those failures, or preserving the full stream safely, would address it. A test that checks what the downstream handler actually reads would help cover this case.

@bokelley

bokelley commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Good catch — the reproduction matches exactly. readAndReplaceBody was calling r.Body.Close() before the size check, so the io.LimitReader had already consumed limit+1 bytes from the underlying reader and the original body was closed before the error was returned. The ObserveOnly pass-through then forwarded whatever remained in the (now-consumed) reader.

Fix pushed in the follow-up commit (5e7d2d9):

  • On both error paths in readAndReplaceBody (I/O error and body-too-large), r.Body is now restored to an io.MultiReader that prepends the already-buffered bytes onto the remainder of the original reader, so the downstream handler always receives the full unmodified stream.
  • r.Body.Close() is moved to the success-only path — on the size-exceeded path there is no close, because the remainder of the original body is still being handed to the handler via the MultiReader.
  • TestMiddlewareObserveOnlyBodyOverLimitPreservesFullStream exercises the exact reproduction case: body "0123456789abcdef" (16 bytes), limit 8, asserts the handler reads all 16 bytes.

The same fix also covers the analogous case at step 11 (signed request with content-digest and an oversized body), though that path is less likely to reach ObserveOnly in practice.


Generated by Claude Code

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — no blocking findings.

Reviewed PR #479: additive, spec-anchored ObserveOnly (warn_for shadow mode) plus a new signingtest helper subpackage under adcp/v3/signing/. The change is non-breaking, carries the correct feat(signing) conventional-commit marker in the active adcp/v3 module, and preserves the default fail-closed verification posture. The malformed-header-pair carve-out (CodeHeaderMalformed still hard-rejects even under ObserveOnly) is correct and tested. No schema/generated-type files, TMP signing envelope internals, identity-agent TEE paths, or protocol-managed skills were touched.

Checked against the decision table:

  • No critical/high/medium findings (rows 1, 4, 5, 8 do not fire).
  • gated_paths is false (row 2 does not fire) despite review_decision=REVIEW_REQUIRED.
  • high_risk is false; no deletions/modifications triggers (rows 3, 5).
  • No prior decision (row 6 n/a).
  • No no-auto-approve team match (row 7 n/a).

Falls through to row 9 → approve.

@bokelley bokelley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainer review: ObserveOnly remains fail-closed for malformed authentication headers and does not weaken the default enforcement path; signingtest stays inside the existing adcp/v3 module. Approved pending CI.

bokelley commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Superseded by #487 solely to satisfy the repository CodeQL ruleset, which GitHub default setup cannot evaluate on fork PRs. The original commit and contributor authorship are preserved in #487.

@bokelley bokelley closed this Sep 4, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — clean pass on an additive signing-middleware feature.

This PR adds an opt-in ObserveOnly shadow-mode option to the AdCP request-signing middleware, a new signingtest test-helper subpackage, and matching docs (MIGRATION.md, README.md, doc.go). The reviewer verified the important TMP-signing-adjacent behaviors: fail-closed default, correct CodeHeaderMalformed hard-reject carve-out, no stale VerifiedSigner on pass-through, INFO-level observability, an additive (feat:) wire contract, and thorough test coverage.

Decision-table walk:

  • No critical/high findings (row 1 n/a).
  • gated_paths is false and review_decision is APPROVED (row 2 n/a).
  • high_risk is false; no deleted/modified high-risk entries (rows 3, 5 n/a).
  • No medium findings at all (rows 4, 8 n/a).
  • No no-auto-approve team match (row 7 n/a).
  • Prior decision was already approve, so row 6 sticky escalation does not apply.

Falls through to row 9 → approve. No inline findings to surface.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

signing: ship signing/signingtest subpackage + ObserveOnly mode

3 participants